package animation;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONTokener;

import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;

/**
 * A state machine for controlling an animation tree.
 */
public class AnimationStateMachine {
	public AnimationStateMachine() {
		states = new HashMap<String, CommandSet>();
		events = new HashMap<String, CommandSet>();
	}

	public void save(Path path)
			throws IOException {
		Writer writer = null;
		try {
			JSONObject rootObj = new JSONObject();

			JSONArray statesArr = new JSONArray();
			rootObj.put("states", statesArr);

			for (Map.Entry<String, CommandSet> e : states.entrySet()) {
				// write command set
				JSONObject commandSetObj = new JSONObject();
				statesArr.put(commandSetObj);

				commandSetObj.put("name", e.getKey());

				JSONArray commandsArr = new JSONArray();
				commandSetObj.put("commands", commandsArr);
				for (int i = 0; i < e.getValue().getCommandCount(); ++i) {
					e.getValue().getCommand(i).save(commandsArr);
				}
			}

			JSONArray eventsArr = new JSONArray();
			rootObj.put("events", eventsArr);

			for (Map.Entry<String, CommandSet> e : events.entrySet()) {
				// write command set
				JSONObject commandSetObj = new JSONObject();
				eventsArr.put(commandSetObj);

				commandSetObj.put("name", e.getKey());

				JSONArray commandsArr = new JSONArray();
				commandSetObj.put("commands", commandsArr);
				for (int i = 0; i < e.getValue().getCommandCount(); ++i) {
					e.getValue().getCommand(i).save(commandsArr);
				}
			}

			writer = Files.newBufferedWriter(path, Charset.defaultCharset());
			rootObj.write(writer);
		} catch (JSONException e) {
			System.out.println("Error: " + e.getMessage());
			throw new IOException("Failed to write " + path);
		} finally {
			if (writer != null) {
				try {
					writer.close();
				} catch (IOException e) {}
			}
		}
	}

	public void load(Path path)
			throws IOException {
		states.clear();
		events.clear();
		Reader reader = null;
		try {
			reader = Files.newBufferedReader(path, Charset.defaultCharset());
			JSONObject rootObj = new JSONObject(new JSONTokener(reader));

			JSONArray statesArr = rootObj.getJSONArray("states");
			for (int i = 0; i < statesArr.length(); ++i) {
				JSONObject stateObj = statesArr.getJSONObject(i);

				String name = stateObj.getString("name");
				if (states.containsKey(name)) {
					throw new IOException("Duplicate state " + name);
				}

				CommandSet cs = addState(name);

				JSONArray commandsArr = stateObj.getJSONArray("commands");
				for (int c = 0; c < commandsArr.length(); ++c) {
					JSONObject commandObj = commandsArr.getJSONObject(c);
					cs.addCommand(cs.getCommandCount(), loadCommand(commandObj));
				}
			}

			JSONArray eventsArr = rootObj.getJSONArray("events");
			for (int i = 0; i < eventsArr.length(); ++i) {
				JSONObject eventObj = eventsArr.getJSONObject(i);

				String name = eventObj.getString("name");
				if (events.containsKey(name)) {
					throw new IOException("Duplicate event " + name);
				}

				CommandSet cs = addEvent(name);

				JSONArray commandsArr = eventObj.getJSONArray("commands");
				for (int c = 0; c < commandsArr.length(); ++c) {
					JSONObject commandObj = commandsArr.getJSONObject(c);
					cs.addCommand(cs.getCommandCount(), loadCommand(commandObj));
				}
			}

		} catch (JSONException e) {
			System.out.println("Error: " + e.getMessage());
			throw new IOException("Failed to read " + path);
		} catch (IOException e) {
			System.out.println("Error: " + e.getMessage());
			throw new IOException("Failed to read " + path);
		} finally {
			if (reader != null) {
				try {
					reader.close();
				} catch (IOException e) {}
			}
		}
	}

	private static Command loadCommand(JSONObject commandObj)
			throws IOException {
		// read the node type
		String typeStr = commandObj.getString("type");
		Command c;
		if (typeStr.equals(Command.Type.ANIMATION_PLAY.toString())) {
			c = new AnimationPlayCommand();
		} else if (typeStr.equals(Command.Type.ANIMATION_SET_TIME.toString())) {
			c = new AnimationSetTimeCommand();
		} else if (typeStr.equals(Command.Type.SELECT_SET_INDEX.toString())) {
			c = new SelectSetIndexCommand();
		} else if (typeStr.equals(Command.Type.BLEND_TRANSITION_WEIGHT.toString())) {
			c = new BlendTransitionWeightCommand();
		} else if (typeStr.equals(Command.Type.BLEND_SET_WEIGHT.toString())) {
			c = new BlendSetWeightCommand();
		} else if (typeStr.equals(Command.Type.SEQUENCE_TRANSITION_WEIGHT.toString())) {
			c = new SequenceTransitionWeightCommand();
		} else if (typeStr.equals(Command.Type.SEQUENCE_SET_WEIGHT.toString())) {
			c = new SequenceSetWeightCommand();
		} else if (typeStr.equals(Command.Type.STATE_SET_ACTIVE.toString())) {
			c = new StateSetActiveCommand();
		} else if (typeStr.equals(Command.Type.EVENT_TRIGGER.toString())) {
			c = new EventTriggerCommand();
		} else {
			throw new IOException("Invalid command type " + typeStr);
		}

		c.loadData(commandObj);
		return c;
	}

	public static class FloatBinding {
		public FloatBinding(String variable) {
			this.variable = variable;
		}

		public FloatBinding(float constant) {
			this.constant = constant;
		}

		public String getVariable() {
			return variable;
		}

		public float getConstant() {
			return constant;
		}

		// if true, contains variable; otherwise, contains constant value
		public boolean isBoundToVariable() {
			return variable != null;
		}

		private String variable;
		private float constant;

		private void save(JSONObject obj, String name) {
			if (isBoundToVariable()) {
				obj.put(name, "v:" + getVariable());
			} else {
				obj.put(name, "c:" + Float.toString(getConstant()));
			}
		}

		private static FloatBinding load(JSONObject obj, String name)
				throws IOException {
			String str = obj.getString(name);
			if (str.startsWith("v:")) {
				return new FloatBinding(str.substring(2));
			} else if (str.startsWith("c:")) {
				try {
					return new FloatBinding(Float.parseFloat(str.substring(2)));
				} catch (NumberFormatException e) {
					throw new IOException("Invalid FloatBinding attribute " + name);
				}
			} else {
				throw new IOException("Invalid FloatBinding attribute " + name);
			}
		}
	}

	public static class IntBinding {
		public IntBinding(String variable) {
			this.variable = variable;
		}

		public IntBinding(int constant) {
			this.constant = constant;
		}

		public String getVariable() {
			return variable;
		}

		public int getConstant() {
			return constant;
		}

		// if true, contains variable; otherwise, contains constant value
		public boolean isBoundToVariable() {
			return variable != null;
		}

		private String variable;
		private int constant;

		private void save(JSONObject obj, String name) {
			if (isBoundToVariable()) {
				obj.put(name, "v:" + getVariable());
			} else {
				obj.put(name, "c:" + Integer.toString(getConstant()));
			}
		}

		private static IntBinding load(JSONObject obj, String name)
				throws IOException {
			String str = obj.getString(name);
			if (str.startsWith("v:")) {
				return new IntBinding(str.substring(2));
			} else if (str.startsWith("c:")) {
				try {
					return new IntBinding(Integer.parseInt(str.substring(2)));
				} catch (NumberFormatException e) {
					throw new IOException("Invalid IntBinding attribute " + name);
				}
			} else {
				throw new IOException("Invalid IntBinding attribute " + name);
			}
		}
	}

	public static abstract class Command {
		public enum Type {
			ANIMATION_PLAY,
			ANIMATION_SET_TIME,
			SELECT_SET_INDEX,
			BLEND_TRANSITION_WEIGHT,
			BLEND_SET_WEIGHT,
			SEQUENCE_TRANSITION_WEIGHT,
			SEQUENCE_SET_WEIGHT,
			STATE_SET_ACTIVE,
			EVENT_TRIGGER
		}

		private Command(Type type) {
			this.type = type;
		}

		public Type getType() {
			return type;
		}

		private Type type;

		protected void save(JSONArray commandsArr) {
			JSONObject commandObj = new JSONObject();
			commandsArr.put(commandObj);

			commandObj.put("type", getType().toString());
			saveData(commandObj);
		}

		protected abstract void saveData(JSONObject commandObj);
		protected abstract void loadData(JSONObject commandObj) throws IOException;
	}

	public static class AnimationPlayCommand extends Command {
		public AnimationPlayCommand() {
			super(Type.ANIMATION_PLAY);
			loop = false;
			speed = new FloatBinding(1.0f);
		}

		public void setNodeName(String nodeName) {
			this.nodeName = nodeName;
		}

		public String getNodeName() {
			return nodeName;
		}

		public void setLoop(boolean loop) {
			this.loop = loop;
		}

		public boolean getLoop() {
			return loop;
		}

		public void setSpeed(FloatBinding speed) {
			this.speed = speed;
		}

		public FloatBinding getSpeed() {
			return speed;
		}

		private String nodeName;
		private boolean loop;
		private FloatBinding speed;

		protected void saveData(JSONObject commandObj) {
			commandObj.put("nodeName", nodeName);
			commandObj.put("loop", loop);
			speed.save(commandObj, "speed");
		}

		protected void loadData(JSONObject commandObj)
				throws IOException {
			nodeName = commandObj.getString("nodeName");
			loop = commandObj.getBoolean("loop");
			speed = FloatBinding.load(commandObj, "speed");
		}
	}

	public static class AnimationSetTimeCommand extends Command {
		public AnimationSetTimeCommand() {
			super(Type.ANIMATION_SET_TIME);
			time = new FloatBinding(0.0f);
		}

		public void setNodeName(String nodeName) {
			this.nodeName = nodeName;
		}

		public String getNodeName() {
			return nodeName;
		}

		public void setTime(FloatBinding time) {
			this.time = time;
		}

		public FloatBinding getTime() {
			return time;
		}

		private String nodeName;
		private FloatBinding time;

		protected void saveData(JSONObject commandObj) {
			commandObj.put("nodeName", nodeName);
			time.save(commandObj, "time");
		}

		protected void loadData(JSONObject commandObj)
				throws IOException {
			nodeName = commandObj.getString("nodeName");
			time = FloatBinding.load(commandObj, "time");
		}
	}

	public static class SelectSetIndexCommand extends Command {
		public SelectSetIndexCommand() {
			super(Type.SELECT_SET_INDEX);
			index = new IntBinding(0);
		}

		public void setNodeName(String nodeName) {
			this.nodeName = nodeName;
		}

		public String getNodeName() {
			return nodeName;
		}

		public void setIndex(IntBinding index) {
			this.index = index;
		}

		public IntBinding getIndex() {
			return index;
		}

		private String nodeName;
		private IntBinding index;

		protected void saveData(JSONObject commandObj) {
			commandObj.put("nodeName", nodeName);
			index.save(commandObj, "index");
		}

		protected void loadData(JSONObject commandObj)
				throws IOException {
			nodeName = commandObj.getString("nodeName");
			index = IntBinding.load(commandObj, "index");
		}
	}

	public static class BlendTransitionWeightCommand extends Command {
		public BlendTransitionWeightCommand() {
			super(Type.BLEND_TRANSITION_WEIGHT);
			weight = new FloatBinding(0.0f);
			speed = new FloatBinding(1.0f);
		}

		public void setNodeName(String nodeName) {
			this.nodeName = nodeName;
		}

		public String getNodeName() {
			return nodeName;
		}

		public void setWeight(FloatBinding weight) {
			this.weight = weight;
		}

		public FloatBinding getWeight() {
			return weight;
		}

		public void setSpeed(FloatBinding speed) {
			this.speed = speed;
		}

		public FloatBinding getSpeed() {
			return speed;
		}

		private String nodeName;
		private FloatBinding weight;
		private FloatBinding speed;

		protected void saveData(JSONObject commandObj) {
			commandObj.put("nodeName", nodeName);
			weight.save(commandObj, "weight");
			speed.save(commandObj, "speed");
		}

		protected void loadData(JSONObject commandObj)
				throws IOException {
			nodeName = commandObj.getString("nodeName");
			weight = FloatBinding.load(commandObj, "weight");
			speed = FloatBinding.load(commandObj, "speed");
		}
	}

	public static class BlendSetWeightCommand extends Command {
		public BlendSetWeightCommand() {
			super(Type.BLEND_SET_WEIGHT);
			weight = new FloatBinding(0.0f);
		}

		public void setNodeName(String nodeName) {
			this.nodeName = nodeName;
		}

		public String getNodeName() {
			return nodeName;
		}

		public void setWeight(FloatBinding weight) {
			this.weight = weight;
		}

		public FloatBinding getWeight() {
			return weight;
		}

		private String nodeName;
		private FloatBinding weight;

		protected void saveData(JSONObject commandObj) {
			commandObj.put("nodeName", nodeName);
			weight.save(commandObj, "weight");
		}

		protected void loadData(JSONObject commandObj)
				throws IOException {
			nodeName = commandObj.getString("nodeName");
			weight = FloatBinding.load(commandObj, "weight");
		}
	}

	public static class SequenceTransitionWeightCommand extends Command {
		public SequenceTransitionWeightCommand() {
			super(Type.SEQUENCE_TRANSITION_WEIGHT);
			index = 0;
			weight = new FloatBinding(0.0f);
			speed = new FloatBinding(1.0f);
		}

		public void setNodeName(String nodeName) {
			this.nodeName = nodeName;
		}

		public String getNodeName() {
			return nodeName;
		}

		public void setIndex(int index) {
			this.index = index;
		}

		public int getIndex() {
			return index;
		}

		public void setWeight(FloatBinding weight) {
			this.weight = weight;
		}

		public FloatBinding getWeight() {
			return weight;
		}

		public void setSpeed(FloatBinding speed) {
			this.speed = speed;
		}

		public FloatBinding getSpeed() {
			return speed;
		}

		private String nodeName;
		private int index;
		private FloatBinding weight;
		private FloatBinding speed;

		protected void saveData(JSONObject commandObj) {
			commandObj.put("nodeName", nodeName);
			commandObj.put("index", index);
			weight.save(commandObj, "weight");
			speed.save(commandObj, "speed");
		}

		protected void loadData(JSONObject commandObj)
				throws IOException {
			nodeName = commandObj.getString("nodeName");
			index = commandObj.getInt("index");
			weight = FloatBinding.load(commandObj, "weight");
			speed = FloatBinding.load(commandObj, "speed");
		}
	}

	public static class SequenceSetWeightCommand extends Command {
		public SequenceSetWeightCommand() {
			super(Type.SEQUENCE_SET_WEIGHT);
			index = 0;
			weight = new FloatBinding(0.0f);
		}

		public void setNodeName(String nodeName) {
			this.nodeName = nodeName;
		}

		public String getNodeName() {
			return nodeName;
		}

		public void setIndex(int index) {
			this.index = index;
		}

		public int getIndex() {
			return index;
		}

		public void setWeight(FloatBinding weight) {
			this.weight = weight;
		}

		public FloatBinding getWeight() {
			return weight;
		}

		private String nodeName;
		private int index;
		private FloatBinding weight;

		protected void saveData(JSONObject commandObj) {
			commandObj.put("nodeName", nodeName);
			commandObj.put("index", index);
			weight.save(commandObj, "weight");
		}

		protected void loadData(JSONObject commandObj)
				throws IOException {
			nodeName = commandObj.getString("nodeName");
			index = commandObj.getInt("index");
			weight = FloatBinding.load(commandObj, "weight");
		}
	}

	public static class StateSetActiveCommand extends Command {
		public StateSetActiveCommand() {
			super(Type.STATE_SET_ACTIVE);
			active = false;
		}

		public void setStateName(String stateName) {
			this.stateName = stateName;
		}

		public String getStateName() {
			return stateName;
		}

		public void setActive(boolean active) {
			this.active = active;
		}

		public boolean getActive() {
			return active;
		}

		private String stateName;
		private boolean active;

		protected void saveData(JSONObject commandObj) {
			commandObj.put("stateName", stateName);
			commandObj.put("active", active);
		}

		protected void loadData(JSONObject commandObj)
				throws IOException {
			stateName = commandObj.getString("stateName");
			active = commandObj.getBoolean("active");
		}
	}

	public static class EventTriggerCommand extends Command {
		public EventTriggerCommand() {
			super(Type.EVENT_TRIGGER);
		}

		public void setEventName(String eventName) {
			this.eventName = eventName;
		}

		public String getEventName() {
			return eventName;
		}

		private String eventName;

		protected void saveData(JSONObject commandObj) {
			commandObj.put("eventName", eventName);
		}

		protected void loadData(JSONObject commandObj)
				throws IOException {
			eventName = commandObj.getString("eventName");
		}
	}

	// a set of commands
	public static class CommandSet {
		public CommandSet() {
			commands = new ArrayList<Command>();
		}

		public int getCommandCount() {
			return commands.size();
		}

		public void addCommand(int index, Command command) {
			commands.add(index, command);
		}

		public void removeCommand(int index) {
			commands.remove(index);
		}

		public Command getCommand(int index) {
			return commands.get(index);
		}

		private ArrayList<Command> commands;
	}

	public CommandSet addState(String name) {
		if (states.containsKey(name)) {
			throw new IllegalArgumentException("State " + name + " already exists");
		}
		CommandSet cs = new CommandSet();
		states.put(name, cs);
		return cs;
	}

	public void removeState(String name) {
		if (states.remove(name) == null) {
			throw new IllegalArgumentException("State " + name + " does not exist");
		}
	}

	public void setStateName(String oldName, String newName) {
		if (!states.containsKey(oldName)) {
			throw new IllegalArgumentException("State " + oldName + " does not exist");
		}
		if (oldName.equals(newName)) {
			return;
		}
		if (states.containsKey(newName)) {
			throw new IllegalArgumentException("State " + newName + " already exists");
		}
		CommandSet cs = states.remove(oldName);
		states.put(newName, cs);
	}

	public CommandSet getState(String name) {
		return states.get(name);
	}

	public CommandSet addEvent(String name) {
		if (events.containsKey(name)) {
			throw new IllegalArgumentException("Event " + name + " already exists");
		}
		CommandSet cs = new CommandSet();
		events.put(name, cs);
		return cs;
	}

	public void removeEvent(String name) {
		if (events.remove(name) == null) {
			throw new IllegalArgumentException("Event " + name + " does not exist");
		}
	}

	public void setEventName(String oldName, String newName) {
		if (!events.containsKey(oldName)) {
			throw new IllegalArgumentException("Event " + oldName + " does not exist");
		}
		if (oldName.equals(newName)) {
			return;
		}
		if (events.containsKey(newName)) {
			throw new IllegalArgumentException("Event " + newName + " already exists");
		}
		CommandSet cs = events.remove(oldName);
		events.put(newName, cs);
	}

	public CommandSet getEvent(String name) {
		return events.get(name);
	}

	public Set<String> getStates() {
		return Collections.unmodifiableSet(states.keySet());
	}

	public Set<String> getEvents() {
		return Collections.unmodifiableSet(events.keySet());
	}

	// map of states
	private HashMap<String, CommandSet> states;

	// map of events
	private HashMap<String, CommandSet> events;
}
